// Copyright 2012 Google Inc. All Rights Reserved.

package com.google.superlists.servlet;

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.appengine.http.urlfetch.UrlFetchTransport;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.HttpHeaders;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson.JacksonFactory;
import com.google.api.client.util.escape.CharEscapers;
import com.google.api.services.calendar.Calendar;
import com.google.api.services.drive.Drive;
import com.google.api.services.tasks.Tasks;
import com.google.common.base.Stopwatch;
import com.google.common.collect.Maps;
import com.google.gson.reflect.TypeToken;
import com.google.superlists.auth.CredentialMediator;
import com.google.superlists.auth.CredentialMediator.InvalidClientSecretsException;
import com.google.superlists.model.ActionResult;
import com.google.superlists.model.JsonUtil;

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Base class for all actions to extend. Actions are created per-request so they don't need to be
 * thread safe and can store fields locally rather than passing them around everywhere.
 *
 * @author sven@google.com (Sven Mawson)
 */
public abstract class BaseAction implements Action {

  /**
   * Path component under war/ to locate client_secrets.json file.
   */
  private static final String CLIENT_SECRETS_FILE_PATH = "/WEB-INF/client_secrets.json";

  /**
   * Scopes for which to request access from the user.
   */
  private static final List<String> SCOPES = Arrays.asList(
  // Required to access and manipulate files.
      "https://www.googleapis.com/auth/drive.file",
      // Required to identify the user in our data store.
      "https://www.googleapis.com/auth/userinfo.email",
      "https://www.googleapis.com/auth/userinfo.profile",
      // Required for calendar and tasks
      "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/tasks");

  private static final HttpTransport TRANSPORT = new UrlFetchTransport();
  private static final JsonFactory JSON_FACTORY = new JacksonFactory();
  private static final String APPLICATION_NAME = "IOSuperLists/1.0";

  private static final String CACHED_CREDENTIAL =
      "com.google.superlists.servlet.BaseAction.CACHED_CREDENTIAL";

  protected final HttpServlet servlet;
  protected final HttpServletRequest req;
  protected final HttpServletResponse resp;

  private final Map<String, Stopwatch> timers = Maps.newLinkedHashMap();
  private final Map<String, String> metrics = Maps.newLinkedHashMap();

  protected BaseAction(HttpServlet servlet, HttpServletRequest req, HttpServletResponse resp) {
    this.servlet = servlet;
    this.req = req;
    this.resp = resp;
  }

  @Override
  public final void execute() throws IOException {
    String name = getClass().getSimpleName();
    start(name + ".execute()");

    loadMetrics();

    start(name + ".auth()");
    getCredential();
    stop(name + ".auth()");

    ActionResult result;
    try {
      result = handle();
    } catch (GoogleJsonResponseException e) {
      if (e.getStatusCode() == 401) {
        // The user has revoked our token or it is otherwise bad.
        // Delete the credential and recursively try again.
        deleteCredential();
        execute();
        return;
      }
      sendError(500, "Server Error, Ack!.");
      throw new RuntimeException("Server Error.", e);
    }

    // Log total request time.
    stop(name + ".execute()");

    // Set the metrics into the request, in case we need them.
    req.setAttribute("metrics", metrics);

    // If there was a result, write it.
    if (result != null) {
      result.metrics = metrics;
      resp.getWriter().println(result.toJson());
    }
  }

  /**
   * Handle the request. Subclasses must implement. May be called multiple times per request if the
   * credentials needed to be re-authorized.
   */
  protected abstract ActionResult handle() throws IOException;

  /**
   * Starts the timer. Use {@link #stop} to end the timer and log it.
   */
  protected Stopwatch start(String name) {
    Stopwatch timer = timers.get(name);
    if (timer == null) {
      timer = new Stopwatch();
      timers.put(name, timer);
    }
    return timer.reset().start();
  }

  /**
   * Stops the timer and logs the time spent into the metrics map.
   */
  protected Stopwatch stop(String name) {
    Stopwatch timer = timers.get(name);
    if (timer == null) {
      throw new IllegalArgumentException("Missing start for timer: " + name);
    }
    addMetric(name, timer.stop().toString());
    return timer;
  }

  /** Adds a numeric metric to the metrics. */
  protected void addMetric(String name, long value) {
    String existing = metrics.get(name);
    if (existing != null) {
      try {
        long existingValue = Long.parseLong(existing);
        value = existingValue + value;
      } catch (NumberFormatException nfe) {
        // Ignore, fall through.
      }
    }
    setMetric(name, value);
  }

  /**
   * Sets the value of a metric, overriding any previous value there.
   */
  protected void setMetric(String name, long value) {
    addMetric(name, Long.toString(value));
  }

  /**
   * Adds a metric to the collected metrics so far.
   */
  protected void addMetric(String name, String value) {
    metrics.put(name, value);
  }

  /** Adds the content length from the response headers. */
  protected void addContentLength(String name, HttpHeaders responseHeaders) {
    if (responseHeaders == null) {
      return;
    }
    String contentLength = responseHeaders.getContentLength();
    if (contentLength == null) {
      return;
    }
    try {
      addMetric(name, Long.parseLong(contentLength));
    } catch (NumberFormatException nfe) {
      // Ignore, skip tracking this invalid content length.
    }
  }

  /**
   * Loads metrics from the request string, if they were present.
   */
  protected void loadMetrics() {
    String metricsParam = req.getParameter("metrics");
    if (metricsParam != null) {
      Map<String, String> fromRequest =
          JsonUtil.fromJson(metricsParam, new TypeToken<Map<String, String>>() {}.getType());
      metrics.putAll(fromRequest);
    }
  }

  /**
   * Returns the metrics contained in this action.
   */
  protected Map<String, String> getMetrics() {
    return metrics;
  }

  /**
   * Creates a new callback for batch processing.
   */
  protected <T> FutureCallback<T> createCallback(String... names) {
    return FutureCallback.create(this, names);
  }

  /**
   * Returns true if the request enabled partial responses.
   */
  protected boolean usePartialResponses() {
    return optionEnabled(1);
  }

  /**
   * Returns true if the request enabled partial updates.
   */
  protected boolean usePartialUpdate() {
    return optionEnabled(3);
  }

  /**
   * Returns true if the request enabled batch processing.
   */
  protected boolean useBatch() {
    return optionEnabled(5);
  }

  /**
   * Returns true if the given option is enabled.
   */
  protected boolean optionEnabled(int index) {
    Cookie optionsCookie = getCookie("options");
    if (optionsCookie == null) {
      return false;
    }
    return (optionsCookie.getValue().charAt(index) == '1');
  }

  /**
   * Returns the cookie with the given name.
   */
  protected Cookie getCookie(String name) {
    for (Cookie cookie : req.getCookies()) {
      if (name.equals(cookie.getName())) {
        return cookie;
      }
    }
    return null;
  }

  /**
   * Redirects to the given url, and adds metrics to the end of the URL.
   */
  protected void redirect(String url) throws IOException {
    boolean hasParams = (url.indexOf('?') != -1);

    String metricValue = CharEscapers.escapeUriQuery(JsonUtil.toJson(metrics));
    if (hasParams) {
      url += "&metrics=" + metricValue;
    } else {
      url += "?metrics=" + metricValue;
    }
    resp.sendRedirect(url);
  }

  /**
   * Sends an error response to the caller.
   */
  protected void sendError(int code, String message) {
    try {
      resp.sendError(code, message);
    } catch (IOException e) {
      throw new RuntimeException(message);
    }
  }

  /** Returns the client secrets stream. */
  private InputStream getClientSecretsStream() {
    return servlet.getServletContext().getResourceAsStream(CLIENT_SECRETS_FILE_PATH);
  }

  /** Returns the credential mediator for the given request. */
  private CredentialMediator getCredentialMediator() {
    // Authorize or fetch credentials. Required here to ensure this happens
    // on first page load. Then, credentials will be stored in the user's
    // session.
    CredentialMediator mediator;
    try {
      mediator = new CredentialMediator(req, getClientSecretsStream(), SCOPES);
      mediator.getActiveCredential();
      return mediator;
    } catch (CredentialMediator.NoRefreshTokenException e) {
      try {
        resp.sendRedirect(e.getAuthorizationUrl());
      } catch (IOException ioe) {
        throw new RuntimeException("Failed to redirect user for authorization");
      }
      throw new RuntimeException("No refresh token found. Re-authorizing.");
    } catch (InvalidClientSecretsException e) {
      String message = String.format(
          "This application is not properly configured: %s", e.getMessage());
      sendError(500, message);
      throw new RuntimeException(message);
    }
  }

  /**
   * Returns the credentials for the given request.
   */
  protected Credential getCredential() {
    Credential cached = (Credential) req.getAttribute(CACHED_CREDENTIAL);
    if (cached != null) {
      return cached;
    }
    try {
      CredentialMediator mediator = getCredentialMediator();
      Credential credential = mediator.getActiveCredential();
      req.setAttribute(CACHED_CREDENTIAL, credential);
      return credential;
    } catch (CredentialMediator.NoRefreshTokenException e) {
      try {
        resp.sendRedirect(e.getAuthorizationUrl());
      } catch (IOException ioe) {
        ioe.printStackTrace();
        throw new RuntimeException("Failed to redirect for authorization.");
      }
    }
    return null;
  }

  /**
   * Deletes the current credential.
   */
  protected void deleteCredential() {
    CredentialMediator mediator = getCredentialMediator();
    mediator.deleteActiveCredential();
  }

  /**
   * Returns the clientId from the client secrets.
   */
  protected String getClientId() {
    return getCredentialMediator().getClientSecrets().getWeb().getClientId();
  }

  /**
   * Build and return a Drive service object based on a credential.
   */
  protected Drive getDriveService() {
    Credential credential = getCredential();
    return Drive.builder(TRANSPORT, JSON_FACTORY)
        .setApplicationName(APPLICATION_NAME)
        .setHttpRequestInitializer(credential).build();
  }

  /**
   * Build and return a Tasks service object based on a credential.
   */
  protected Calendar getCalendarService() {
    Credential credential = getCredential();
    return Calendar.builder(TRANSPORT, JSON_FACTORY)
        .setApplicationName(APPLICATION_NAME)
        .setHttpRequestInitializer(credential).build();
  }

  /**
   * Build and return a Tasks service object based on a credential.
   */
  protected Tasks getTasksService() {
    Credential credential = getCredential();
    return Tasks.builder(TRANSPORT, JSON_FACTORY)
        .setApplicationName(APPLICATION_NAME)
        .setHttpRequestInitializer(credential).build();
  }
}
